作者:给立乐*
出处:http://spencer-dev.com/2017/07/17/Clean Code - Chapter 14 逐步改进
声明:本文采用以下协议进行授权: 自由转载-非商用-非衍生-保持署名|Creative Commons BY-NC-ND 3.0 ,转载请注明作者及出处。
逐步改进
这是对一个命令行参数解析程序的案例研究。这是一个逐步改进的过程。你将看到一开始还不错,规模扩大后即出现问题的模块。你还将看到这个模块是如何被重构的整洁起来的。
我们中的大多数人都会遇到解析命令行参数的情况。如果没有趁手的工具,就得遍历传入 main 函数的字符串数组。有一些不同来源的好工具,但没有一个是最符合要求的。所以,我当然要自己写一个。我把它叫做 Args。
Args 非常易于使用。你只要简单地用输入参数和格式化字符串构造 Args 类,再向 Args 实体询问参数值即可。看看下面的简单例子:
|
|
可以看到这有多简单。我们只是用两个参数创建了 Args 类的一个实体。第一个参数是格式字符串,或范式字符串:1,p#,d*。它定义了三个命令行参数。第一个,-l,是一个布尔值参数。第二个,-p,是一个整数参数。第三个,-d,是一个字符串参数。向 Args 构造器传入的第二个参数就是向 main 传入的命令行参数数组。
如果构造器返回正常,没有抛出 ArgsException 异常,则命令行参数已传入,Args 实体随时待命。使用 getBoolean、getInteger、getString 等方法,可以用参数名称获得参数值。
不管是格式化字符串或命令行参数出现问题、就会抛出一个 ArgsException 异常。可以从该异常的 errorMessage 中获得关于错误的描述。
Args 的实现
|
|
|
|
|
|
|
|
|
|
|
|
Args:草稿
能工作,但却很烂的版本
|
|
显然上面这段代码还需要打磨。实体变量的数量多到吓人。诸如 TILT 之类奇怪的字符串, HashSet 和 TreeSet,还有那些 try-catch-catch 代码块,组成了一个烂摊子。
我不想写出一个烂摊子。我也想保持一切有序。从函数和变量命名,以及程序的粗略架构中,你可以看出这一点。不过,显然我没能做到。
混乱是逐渐产生的。更早的版本并不如此肮脏。例如下面的代码,那时候只支持 Boolean 参数。
|
|
尽管你可能对这段代码很不满意,其实它并非如此之烂。它精炼、简单、易于理解。然而,在这段代码中很容易找到后面烂摊子的根源。很清楚能看到小问题如何变成大混乱的。
注意,后来的混乱只比这个版本多支持两种参数类型 :String 和 Integer。只增加了两种参数类型支持,就对代码产生了如此巨大的负面影响。它从某种可维护之物变成了满是缺陷的东西。
我逐步添加了对这两种参数类型的支持。
首先,我添加了对 String 参数的支持,就像这样:
|
|
你可以看到,代码开始失去控制。还算不上可怕,但混乱已经开始生长。已经出现了一堆东西,不过还没烂掉。增加对整数参数类型的支持后,那堆东西就真的变质腐烂了。
所以我暂停了
还有至少两种参数类型要添加,而且情形一定会更加糟糕。如果一味蛮干,大概也能让它工作,不过就会留下一大堆要调整的混乱。如果希望代码结构一直可维护,现在就是调整的时机了。
所以我暂停添加特性,开始重构。由于刚添加了 String 和 Integer 参数,我知道每种参数类型都需要在三个主要位置增加新代码。首先,每种参数类型都要有解析其范式元素、从而为该种类型选择 HashMap 的方法。其次,每种参数类型都需要在命令行字符串中解析,然后再转换为真实类型。最后,每种参数类型都需要一个 getXXX 方法,按照其真实类型向调用者返回参数值。
许多种不同类型,类似的方法 - 听起来像是个类。ArgumentMarshaler 的概念就是这样产生的。
渐进
毁坏程序的最好方法之一就是以改进之名大动其结构。有些程序永远不能从这种所谓“改进”中恢复过来。问题在于,很难让程序以“改进”之前的方式工作。
为了避免这种状况发生,我采用了测试驱动开发的规程。这种手法的核心原则之一是保持系统始终能运行。换言之,采用 TDD,我不会允许做出破坏系统的修改。每次修改都必须保证系统能像以前一样工作。
我需要一套能随需运行、确保系统行为不会改动的自动化测试。在我搞出那个烂摊子的同时,也为 Args 类创建了一套单元测试和验收测试。单元测试用 Java 写成,采用 JUnit 管理。验收测试用 FitNesse 以 wiki 页形式写成。我可以随时运行这些测试,如果测试通过,就能打包票说系统以我期望的方式工作。
于是我开始做出大量小规模修改。每次修改都将系统结构向 ArgumentMarshaler 概念的方向推动。而且每次修改后,系统都要能工作。第一个修改是在烂摊子末尾添加 ArgumentMarshaler 的轮廓。
|
|
显然,这什么也不会破坏。于是我做了一点最简单的、破坏性尽可能小的修改。我修改了 HashMap,采用 ArgumentMarshaler,使之支持 Boolean 参数。
|
|
这个影响到少数语句,我很快就修正了。
|
|
注意,这些修改正是在我之前提到的那些区域之内所做的:参数类型的 parse、set 和 get 操作。不幸的是,即便修改如此细微,有些测试还是会失败。仔细看 getBoolean,可以看到如果用 y 去调用、而并没有 y 这个参数,则 booleanArgs.get(‘y’) 就会返回 null 值,函数将抛出抛出一个 NullPointerException 异常。函数 falseIfNull 用以防止这种状况发生,但我做出的修改却导致该函数无所作为。
渐进主义要求我在做其他修改之前迅速修正这个问题。修正并不费劲。我只是把对 null 值的检查移了个位置。再也不用检测 bollean 是否为 null,而是检查 ArgumentMarshaler 是否为 null。
首先,我移除了 getBoolean 函数中的 falseIfNull 调用。现在它没什么用了,所以我也删去了这个函数。测试还是以同样的方式失败,所以我确定没有引入新的错误。
|
|
下一步,我把函数拆解为两行,并把 ArgumentMarshaler 放到它自己的名为 argumentMarshaler 的变量中。我不在意变量名太长,但它却有点啰嗦,把函数搞得支离破碎。所以我把变量名缩短为 am。
|
|
然后再放入检测 null 值的逻辑。
|
|
字符串参数
添加 String 参数和添加 Boolean 参数非常像。我要修改 HashMap,让 parse、set 和 get函数能工作。跟着就是按部就班,但我似乎该把所有的 marshalling(编组)实现放到 ArgumentMarshaler 基类而不是派生类中。
|
|
同样,也是每次修改一个地方,持续运行测试。如果测试出错,在做下一个修改前确保通过。
现在你应该明白我的意图了。一旦我将当前的编组行为放到 ArgumentMarshaler 基类中,就会开始往派生类推入该行为。这样,在我逐渐修改程序的形状时,还能保持一切正常。
下一步显而易见,把 int 参数的相关功能放到 ArgumentMarshaler 里面。同样,也是照方抓药。
|
|
当所有的编组操作都放到了 ArgumentMarshaler 中,我开始向派生类移植功能。第一步是把 setBoolean 函数放到 BooleanArgumentMarshaler 中,确保它能正确调用。所以我创建了一个抽象的 set 方法。
|
|
然后在 BooleanArgumentMarshaler 中实现 set 方法。
|
|
最后,通过调用 set,替换对 setBoolean 的调用。
|
|
测试仍然全部通过。因为这次修改导致 set 函数放到了 BooleanArgumentMarshaler 里面,我就从 ArgumentMarshaler 基类删除了 setBoolean 方法。
注意,抽象函数 set 有一个 String 参数,但其在 BooleanArgumentMarshaler 中的实现却没有使用这个参数。之所以在这里放个参数,是因为我知道 StringArgumentMarshaler 和 IntegerArgumentMarshaler 可能会使用它。
跟着,我打算把 get 方法放到 BooleanArgumentMarshaler 中。这有点难看,因为返回类型必须是 Object,且在这里需要转换为 Boolean 值。
|
|
为了编译通过,我把 get 函数加到 ArgumentMarshaler 中。
|
|
这样一来,虽然可以编译,但却无法通过测试。只要将 get 修改为抽象方法,并在 BooleanArgumentMarshaler 中实现,就能重新通过测试。
|
|
测试又通过了。get 和 set 方法都已部署到 BooleanArgumentMarshaler 中!这样我就可以从 ArgumentMarshaler 里面移除旧的 getBoolean 函数,把受保护的 booleanValue 变量向下移动到 BooleanArgumentMarshaler,并将其设置为 private。
对于 String 也照此办理。我修改了 set 和 get 的部署方式,删除无用的函数,并移动了变量。
|
|
最后,我为 Integer 类型参数重复这个过程。这稍稍复杂一点,因为 Integer 需要解析,而 parse 操作会抛出异常。不过结果会更好,因为 NumberFormatException 的概念在 IntegerArgumentMarshaler 中隐藏了。
|
|
测试当然继续通过。下一步,我要删掉算法顶端的三种不同 Map。这样,整个系统就变得更通用了。不过,只是删除它们却无法达到目的,因为那样会破坏系统。反之,我为 ArgumentMarshaler 添加一个新的 Map,然后再逐个修改那些方法,让方法调用这个新 Map。
|
|
当然,测试还是通过了。接着,我把 isBooleanArg:
|
|
修改成这样:
|
|
测试仍然通过。于是我修改了一下 isIntArg 和 isStringArg。
|
|
测试继续通过。我跟着消除了对 marshaler.get() 的重复调用:
|
|
存在三个 isXxxArg 方法毫无道理。所以我做了内联修改:
|
|
下一步,我开始在 set 函数中使用 marshaler 映射,停止使用另外三个映射映射。从 Boolean 开始:
|
|
测试通过,于是我如法炮制 String 和 Integer 参数。这样我就能把有些丑陋的异常管理代码整合到 setArgument 函数中。
|
|
离彻底删除那 3 个旧映射的时机越来越近了。首先,我需要修改 getBoolean 函数:
|
|
修改成这样:
|
|
最后这个修改可能令人吃惊。为什么我会突然决定对付 ClassCastException ?原因是我有一组单元测试,还有用 FitNesse 编写的一组验收测试。FitNesse 测试确认,如果用非布尔值参数调用 getBoolean,应该返回 false。可单元测试的结果不是这样。而到此时为止,我一直只调用单元测试。
这次修改把另一个对 Boolean 映射的使用抽离了:
|
|
如此我们就能删除 Boolean 映射。
|
|
接下来,我用同样的手法处理 String 和 Integer 参数,对 Boolean 参数做了一点清理工作。
|
|
接着,由于那些 parse 方法没有太多事情可做,我对它们进行了内联修改:
|
|
行了,下面来看看全景吧。Args 类的现状:
|
|
功夫费尽,还是有点失望。程序结构好了一点,但在代码顶端还是有那一堆变量;在 setArgument 里面还是有那么恐怖的类型转换操作;而且那些 set 函数真的很丑陋。就别提那些错误处理操作了。前头要做的事还很多。
我真想删除掉 setArgument 里面那些类型转换操作。我想要 setArgument 只简单地调用 ArgumentMarshaler.set()。这意味着我需要将 setIntArg、setStringArg 和 setBooleanArg 推到合适的 ArgumentMarshaler 派生类里面。不过这有个问题。
仔细看 setIntArg,你会发现,它使用了两个实体变量:args 和 currentArg。为了把 setIntArg 移到 BooleanArgumentMarshaler 里面,我得把这两个变量都作为函数参数传递过去。那种做法太烂了。我只想传递一个参数。幸运的事,有个简单的解决方法。可以把 args 数组转换为一个 list,并向 set 函数传递一个 Iterator。这花了我 10 步功夫,每次都通过了测试。不过我只想你展示结果。你应该能看出来每个小修改步骤。
|
|
是这些简单的修改让测试保持通过。现在我们可以开始把 set 函数移植到合适的派生类中了。第一步,我要在 setArgument 中作以下修改:
|
|
这个修改很重要,因我们想要彻底删除那条 if-else 语句。所以,需要把错误条件抽离。
现在可以开始移动 set 函数了。setBooleanArg 函数很小,就从它开始。目标是让 setBooleanArg 函数只与 BooleanArgumentMarshaler 有关。
|
|
我们不是刚把那个异常处理放进去吗?放进拿出是重构过程中常见的事。小步幅和保持测试通过,意味着你会不断移动各种东西。重构有点像是解魔方。需要经过许多小步骤,才能达到较大目标。每一步都是下一步的基础。
为什么要在 setBooleanArg 根本不需要的情况下向其传递 Iterator 呢?因为 setIntArg和 setStringArg 需要!还因为我打算通过 ArgumentMarshaler 中的抽象方法部署这三个函数,需要将其传递给 setBooleanArg。
现在 setBooleanArg 没用了。如果 ArgumentMarshaler 中有个 set 函数,我们可以直接调用它。是时候打造那个函数了!第一步,在 ArgumentMarshaler 中添加抽象方法。
|
|
当然,这会影响到所有派生类。所以,要逐个实现新方法。
|
|
现在可以删除 setBooleanArg 了!
|
|
测试全部通过,而且 set 函数也部署到 BooleanArgumentMarshaler 里面了!
现在就能对 String 和 Integer 参数的处理做同样的修改。
|
|
最后一击:可以移除类型转换了!
|
|
现在可以删除 IntegerArgumentMarshaler 中那些过时的函数,做一下清理了。
|
|
还可以把 ArgumentMarshaler 修改为接口。
|
|
现在来看看往这个结构中添加新的参数类型有多容易。只需要做少量修改,而且修改是被隔离的。首先,增加一个新的测试用例,检测 Double 参数是否正常工作。
|
|
然后清理范式解析代码,为 Double 参数类型添加 ## 监测。
|
|
下一步,编写 DoubleArgumentMarshaler 类。
|
|
然后就得添加一个新的 ErrorCode:
|
|
还需要一个 getDouble 函数:
|
|
测试通过。下一个测试确保我们正确监测到遗漏的 Double 参数。
|
|
测试如期通过。我们只是为了保持一切完整而编写的这个测试。
异常代码很丑陋,不该在 Args 类中存在。我们也抛出 ParseException,但那并不真的属于我们自己。那就把所有异常都塞到 ArgsException 类中,并将其移到它自己的模块里面。
|
|
很好。现在,Args 抛出的唯一一个异常是 ArgsException。把 ArgsException 移到它自己的模块中,意味着我们能把大量杂七杂八的错误支持代码从 Args 模块转移到这个模块。
现在我们完全把异常和错误代码从 Args 模块中隔离出来了。为达到这一目标,大概做了 30 次小修改每次修改都保持测试通过。
|
|
对 Args 类所作的主要的修改是在监测部分。从 Args 里面取出了大量代码,放到 ArgsException 中。我们还把全部 ArgumentMarshaler 转移到了它们自己的文件夹中。优秀的软件设计,大都关乎分隔 - 创建合适的空间放置不同种类的代码。对关注面的分隔让代码更易于理解和维护。
特别有意思的是 ArgsException 中的 errorMessage 方法。显然,把错误信息格式化操作放在 Args 里面,违反了 SRP 原则。Args 应该只处理参数,不该去管错误信息的格式。然而,把错误信息格式化代码放到 ArgsException 中是否有道理呢?
实话说,这是种折衷做法。不打算用 ArgsException 提供的错误信息的用户会想自己写错误信息。但如果有备好的错误信息,其方便之处也并非鲜见。
现在,显然我们已经非常接近本章开始处所展示的最终解决方案了。
小结
代码能工作还不够。能工作的代码经常会严重崩溃。满足于仅仅让代码能工作的程序员不够专业。他们会害怕没时间改进代码的结构和设计,我不敢苟同。没什么能比糟糕的代码给开发项目带来更深远和长期的损害了。进度可以重订,需求可以重新定义,团队动态可以修正。但糟糕的代码只是一直腐败发酵,无情地拖着团队的后腿。我无数次看到开发团队蹒跚前行,只因为他们匆匆搞出一片代码沼泽,从此之后命运再也不受自己控制。
当然,糟糕的代码可以清理。不过成本高昂。随着代码腐败下去,模块之间互相渗透,出现大量隐藏纠结的依赖关系。找到和破除陈旧的依赖关系又费时间又费劲。另一方面,保持代码整洁却相对容易。早晨在模块中制造出一堆混乱,下午就能轻易清理掉。更好的情况是,5 分钟之前制造出混乱,马上就能很容易地清理掉。
所以,解决之道就是保持代码持续整洁和简单。永不让腐坏有机会开始。